61 — 用 R3F 制作传送门场景

Three.js Journey · Bruno Simon · 难度:简单

61 — 用 R3F 制作传送门场景 课程海报

简介

让我们继续用 R3F 重复原课程内容的过程。

这一次,我们要重新制作传送门场景。

lesson demo video preview

我们不会重新进行整个建模、烘焙和导出,因为这些步骤与 R3F 无关。

如果你跟着之前的课程做过了,可以使用你自己的传送门,或者使用课程起始文件所提供的那个。

准备工作

起始文件的设置与上一课相同:

场景中心有一个立方体,还有 OrbitControls,这样我们就能绕着它旋转。

我们没有使用 Perf,因为我们不应该有性能方面的顾虑,但如果你愿意,可以随意添加它。

传送门的着色器(顶点和片段)已经位于 /src/shaders/portal/ 中。至于为什么缺少萤火虫的着色器,在后面的课程中就会明白了。

深色背景

这一次,我们要保留原课程的颜色。这意味着我们需要一个更暗的背景。正如我们在之前某一课中所看到的,有多种方法可以做到这一点。

让我们使用附加到场景的 <color> 这种技术。

在 Experience 的 JSX 中,创建一个 <color>:

export default function Experience()
{
    return <>

        <color />

        {/* ... */}

    </>
}

使用 args 属性向它传入一个包含颜色的数组:

<color args={ [ '#030202' ] } />

然后用 attach 属性把它附加到场景的 background 上:

<color args={ [ '#030202' ] } attach="background" />

模型

是时候添加我们的模型了。

加载

模型文件名为 portal.glb,位于 /public/model/ 文件夹中。你可以随意将其替换为你自己的模型。

我们要使用 useGLTF。

从 @react-three/drei 导入 useGLTF:

import { useGLTF, OrbitControls } from '@react-three/drei'

并加载模型(别忘了我们不需要写 public/:

export default function Experience()
{
    const model = useGLTF('./model/portal.glb')
    console.log(model)

    // ...
}

如果你还记得我们创建并导入模型的那一课,它由多个部分组成:

正因如此,我们不会一次性把整个模型添加到场景中。

相反,我们会分别添加每个元素,以便对它们有更多的控制。

而这些元素已经在 nodes 属性中可用了:

const model = useGLTF('./model/portal.glb')
console.log(model.nodes)

这些名称对应我们在 Blender 中选择的名称。如果你使用自己的模型,请确保根据你的配置进行调整。

再说一次,我们可以对它进行解构:

const { nodes } = useGLTF('./model/portal.glb')
console.log(nodes)

烘焙模型

烘焙模型的节点名为 baked,现在我们可以通过 nodes.baked 访问它。

这一次,我们不会使用 <primitive>,因为我们想给 Mesh 应用自己的材质。但我们确实需要它的几何体。

在 JSX 中,创建一个 <mesh>,并把它的几何体设置为 nodes.baked.geometry:

export default function Experience()
{
    // ...

    return <>

        {/* ... */}

        <mesh geometry={ nodes.baked.geometry } />

    </>
}

现在我们有了自己的模型,可以去掉那个立方体了:

默认情况下,<mesh> 上会应用一个 MeshBasicMaterial,这就是为什么我们能看到一些东西。

我们要添加自己的材质,但首先,我们需要加载纹理。

要加载纹理,我们可以使用来自 drei 的 useTexture 辅助函数。

从 @react-three/drei 导入 useTexture:

import { useTexture, useGLTF, OrbitControls } from '@react-three/drei'

现在用它加载位于 '/public/model/baked.jpg' 的纹理(记住我们不需要写 public/):

export default function Experience()
{
    // ...

    const bakedTexture = useTexture('./model/baked.jpg')
    console.log(bakedTexture)

    // ...
}

这就是可以使用、准备就绪的实际纹理。

现在我们可以在 <mesh> 中创建 <meshBasicMaterial>,并在 map 属性上使用我们的纹理:

<mesh geometry={ nodes.baked.geometry }>
    <meshBasicMaterial map={ bakedTexture } />
</mesh>

哎呀,出了点问题。

别担心,这不是模型或纹理的问题。只是纹理的 y 坐标需要翻转。

由于调用 useTexture 时会立即返回纹理,我们可以用 flipY 属性立即翻转它:

const bakedTexture = useTexture('./model/baked.jpg')
bakedTexture.flipY = false

好多了。

居中

我们的模型有点偏高。

我们可以把控制器/相机上移,或者把场景下移。

让我们用来自 drei 的 Center 辅助函数把场景下移

从 @react-three/drei 导入 Center:

import { Center, useTexture, useGLTF, OrbitControls } from '@react-three/drei'

然后把 <mesh> 包裹在 <Center> 中:

<Center>
    <mesh geometry={ nodes.baked.geometry }>
        <meshBasicMaterial map={ bakedTexture } />
    </mesh>
</Center>

柱灯

现在我们可以在柱灯上采用与基础模型类似的做法来添加它们。

在 <Center> 内部,添加一个新的 <mesh>,把它的 geometry 设置为 nodes.poleLightA.geometry:

<Center>
    {/* ... */}

    <mesh geometry={ nodes.poleLightA.geometry } />
</Center>

我们的柱灯在哪里?

仔细看场景的地板:

造成这个问题的原因是,我们创建了一个全新的 <mesh>,从而丢失了初始的 position、transformation 和 scale。

要修复它,我们可以再次从 nodes.poleLightA.position 施加位置:

<mesh geometry={ nodes.poleLightA.geometry } position={ nodes.poleLightA.position } />

现在,我们可以在 <mesh> 中添加 <meshBasicMaterial>,用 #ffffe5 作为它的 color 属性:

<mesh geometry={ nodes.poleLightA.geometry } position={ nodes.poleLightA.position }>
    <meshBasicMaterial color="#ffffe5" />
</mesh>

另一个柱灯的工作方式完全相同。只需复制整个 <mesh>,并把 poleLightA 改成 poleLightB:

<Center>
    {/* ... */}

    <mesh geometry={ nodes.poleLightB.geometry } position={ nodes.poleLightB.position }>
        <meshBasicMaterial color="#ffffe5" />
    </mesh>
</Center>

传送门

就像我们为基础模型和柱灯所做的那样,我们可以添加节点名为 portalLight 的传送门(因为它会发光):

<Center>
    {/* ... */}

    <mesh geometry={ nodes.portalLight.geometry } position={ nodes.portalLight.position }>
        <meshBasicMaterial color="#ffffff" />
    </mesh>
</Center>

在这种情况下,我们还需要添加初始节点的 rotation:

<mesh geometry={ nodes.portalLight.geometry } position={ nodes.portalLight.position } rotation={ nodes.portalLight.rotation }>
    <meshBasicMaterial color="#ffffff" />
</mesh>

现在我们有了模型的所有部分。

颜色

你可能注意到颜色有些奇怪,尤其是当我们给柱灯添加材质时。颜色应该亮得多。

原因是 R3F 默认会设置 toneMapping。

这个 toneMapping 通常是受欢迎的,有了它颜色看起来会更好,但在我们的情况中不想要它,因为我们的场景是从 Blender 烘焙而来的,而 Blender 已经通过它的色彩管理系统应用了色调映射。

我们想要得到与在 Blender 中烘焙时完全相同的颜色。

做好准备:修复这个问题非常复杂!

在 index.jsx 中,给 <Canvas> 添加 flat:

<Canvas
    flat
    camera={ {
        fov: 45,
        near: 0.1,
        far: 50,
        position: [ 1, 2, 6 ]
    } }
>
    <Experience />
</Canvas>

就这样。

在 <Canvas> 上设置 flat 会把 toneMapping 设置为 THREE.NoToneMapping。

萤火虫

是时候添加那些由漂浮在周围发光粒子构成的萤火虫了。

在原课程中,我们用一个自定义着色器创建了它们。

正如我们之前所说的,传送门的着色器已经在项目中可用了,但萤火虫的还没有。

你可能会问为什么?原因是有一个名为 Sparkles 的 drei 辅助函数,它正好能实现我们所需要的功能。

在 Experience.jsx 中,从 @react-three/drei 导入 Sparkles:

import { Sparkles, Center, useTexture, useGLTF, OrbitControls } from '@react-three/drei'

把它添加到 <Center> 中的 JSX 里:

<Center>

    {/* ... */}

    <Sparkles />

</Center>
lesson demo video preview

你几乎看不到它们,但地板附近有一些粒子。

我们可以用 size 属性把它们变大:

<Sparkles
    size={ 6 }
/>

我们可以用 scale 属性改变区域:

<Sparkles
    size={ 6 }
    scale={ [ 4, 2, 4 ] }
/>

它是一个对象,所以我们可以用 position-y 属性把它上移:

<Sparkles
    size={ 6 }
    scale={ [ 4, 2, 4 ] }
    position-y={ 1 }
/>

我们还可以用 speed 属性减慢运动:

<Sparkles
    size={ 6 }
    scale={ [ 4, 2, 4 ] }
    position-y={ 1 }
    speed={ 0.2 }
/>
lesson demo video preview

最后,我们可以用 count 属性减少粒子的数量:

<Sparkles
    size={ 6 }
    scale={ [ 4, 2, 4 ] }
    position-y={ 1 }
    speed={ 0.2 }
    count={ 40 }
/>

传送门

我们快要完成了。剩下的就是添加我们之前用在传送门上的自定义着色器材质。

既然我们在原课程中已经做过了,就不再重新写着色器了。你可以在 /src/shaders/portal/ 文件夹中找到它们。

基础着色器

首先,我们可以把 <meshBasicMaterial> 替换为 <shaderMaterial>:

<mesh geometry={ nodes.portalLight.geometry } position={ nodes.portalLight.position } rotation={ nodes.portalLight.rotation }>
    <shaderMaterial />
</mesh>

我们仍然能看到传送门是红色的,因为 Three.js 提供了默认的顶点和片段着色器。

导入着色器

导入着色器的方式与我们在前几课中所做的完全相同。名为 vite-plugin-glsl 的 Vite 插件已经添加到项目中,我们可以导入 .glsl 文件。

把 ./shaders/portal/vertex.glsl 导入为 portalVertexShader 并打印它:

import portalVertexShader from './shaders/portal/vertex.glsl'
console.log(portalVertexShader)

也导入片段着色器并打印它,只是为了确认它能正常工作:

import portalVertexShader from './shaders/portal/vertex.glsl'
import portalFragmentShader from './shaders/portal/fragment.glsl'
console.log(portalFragmentShader)

实现着色器

现在我们可以在 <shaderMaterial> 上使用我们的着色器,通过 vertexShader 属性和 fragmentShader 属性:

<shaderMaterial
    vertexShader={ portalVertexShader }
    fragmentShader={ portalFragmentShader }
/>

传送门消失了,但这是因为我们没有发送片段着色器所需的 uniforms。

我们需要发送 3 个 uniforms:

我们需要把这些 uniforms 作为 <shaderMaterial> 的 uniforms 属性提供。

对于 uTime,这很容易,因为它是一个数字。但对于 uColorStart 和 uColorEnd,我们需要提供 Three.js 的 Color 实例。

首先,从 three 导入 THREE:

import * as THREE from 'three'

然后把 uniform 属性提供给 <shaderMaterial>:

<shaderMaterial
    vertexShader={ portalVertexShader }
    fragmentShader={ portalFragmentShader }
    uniforms={ {
        uTime: { value: 0 },
        uColorStart: { value: new THREE.Color('#ffffff') },
        uColorEnd: { value: new THREE.Color('#000000') }
    } }
/>

我们有了自己的传送门着色器(尽管它还没有动画),但有一种更简单的方法来完成我们刚才所做的事情。

shaderMaterial 辅助函数

一如既往,drei 提供了一个名为 shaderMaterial 的辅助函数,它会创建一个 ShaderMaterial,然后我们可以在 JSX 中使用它。它还简化了创建和更新 uniforms 的过程。

从 drei 导入 shaderMaterial:

import { shaderMaterial, Sparkles, Center, useTexture, useGLTF, OrbitControls } from '@react-three/drei'

然后,在 Experience 函数之前调用它,并把结果赋值给一个 PortalMaterial 变量:

const PortalMaterial = shaderMaterial()

export default function Experience()
{
    // ...
}

除此之外,我们还需要向它提供 3 个参数。

第一个参数是 uniforms,但不带 value 属性:

const PortalMaterial = shaderMaterial(
    {
        uTime: 0,
        uColorStart: new THREE.Color('#ffffff'),
        uColorEnd: new THREE.Color('#000000')
    },
    // ...
)

第二个和第三个参数分别代表顶点和片段着色器:

const PortalMaterial = shaderMaterial(
    {
        uTime: 0,
        uColorStart: new THREE.Color('#ffffff'),
        uColorEnd: new THREE.Color('#000000')
    },
    portalVertexShader,
    portalFragmentShader
)

现在,要把它转换成我们可以在 JSX 中使用的 R3F 标签,我们将像在本章开头自己实现 OrbitControls 时那样使用 extend。

从 @react-three/fiber 导入 extend:

import { extend } from '@react-three/fiber'

然后在创建 PortalMaterial 之后调用它,并传入一个对象,其属性和值都是 PortalMaterial:

const PortalMaterial = shaderMaterial(
    // ...
)

extend({ PortalMaterial })

这样做会让我们的 JSX 中可以使用 <portalMaterial>。

现在,把 <shaderMaterial> 替换为 <portalMaterial>:

<mesh geometry={ nodes.portalLight.geometry } position={ nodes.portalLight.position } rotation={ nodes.portalLight.rotation }>
    <portalMaterial />
</mesh>

这个解决方案看起来可能并没有简单多少,但它与 R3F 的集成更好,我们可以在多个 <mesh> 中使用 <portalMaterial>,而且 uniforms 也更容易更新。

动画

剩下要做的就是更新 uTime uniform,而使用 shaderMaterial 辅助函数让它变得更容易了。

首先,我们需要一个对材质的引用。

从 react 导入 useRef:

import { useRef } from 'react'

然后,创建一个 portalMaterial 引用:

export default function Experience()
{
    // ...

    const portalMaterial = useRef()

    // ...
}

之后,用 ref 属性把它与 <shaderMaterial> 关联起来:

<portalMaterial ref={ portalMaterial } />

现在从 @react-three/fiber 导入 useFrame 钩子:

import { useFrame, extend } from '@react-three/fiber'

在 Experience 函数中调用它:

export default function Experience()
{
    // ...

    const portalMaterial = useRef()
    useFrame((state, delta) =>
    {

    })

    // ...
}

最后,使用 delta 更新 portalMaterial 引用的 uTime:

useFrame((state, delta) =>
{
    portalMaterial.current.uTime += delta
})
lesson demo video preview

如你所见,我们不必指定 uniforms 或 value。

而且我们仅用了 70 行代码(不含着色器)就完成了传送门!

进一步探索

这一课相当简单,但你现在可以使用 drei 中所有可用的辅助函数。

也许你可以试着添加其中一些。